JWT (JSON Web Token) 是一種小型的「身份證」,用來讓不同的系統之間確認「你是誰」。它很常用在登入系統中。
簡單來說,JWT 就是個「身份證」,幫助伺服器辨識你,避免每次查詢數據庫。
先前我們 Account Service 實作了 Login 的方法,我們要把它當作一個 Authentication Server 來使用,所以每當 User 呼叫完 Login 後,在我們的 Response 內除了 User 的資訊之外,還會有一個 Token 的 Property,我們先前暫時使用 "token"
字串來代替,現在我們實作一個 JWTProvider
來產生這個 Token。
ITokenProvider
我們先在 Account.Application 實作一個 ITokenProvider
介面,並有 GenerateToken
和 ValidateToken
的方法。
namespace Account.Application;
public interface ITokenProvider
{
string GenerateToken(Guid userId, string firstName, string lastName);
string ValidateToken(string token);
}
然後我們 DI ITokenProvider
到 AccountService
中,並且取代 "token"
字串。
using Account.Domain.Aggregates;
namespace Account.Application;
public class AccountService : IAccountService
{
private readonly IUserRepository _userRepository;
private readonly ITokenProvider _tokenProvider;
public AccountService(IUserRepository userRepository, ITokenProvider tokenProvider)
{
_userRepository = userRepository;
_tokenProvider = tokenProvider;
}
public AuthenticationResult Login(string email, string password)
{
var user = _userRepository.GetUserByEmail(email);
if (user == null)
throw new ArgumentException("Email address not exists");
if (!user.VerifyPassword(password))
throw new ArgumentException("Permission denied");
return new AuthenticationResult(
user.Id.Value,
user.FirstName,
user.LastName,
user.Email,
_tokenProvider.GenerateToken(user.Id.Value, user.FirstName, user.LastName)
);
}
public AuthenticationResult Register(string firstName, string lastName, string email, string password)
{
if (_userRepository.GetUserByEmail(email) is not null)
throw new ArgumentException("Email address already exists");
var user = User.Create(firstName, lastName, email, password);
_userRepository.Add(user);
return new AuthenticationResult(
user.Id.Value,
user.FirstName,
user.LastName,
user.Email,
_tokenProvider.GenerateToken(user.Id.Value, user.FirstName, user.LastName)
);
}
}
JwtProvider
這個 JwtProvider 如何實現跟業務邏輯一點關係都沒有,屬於外部的技術實作,故我們放在 Infrastructure Layer。
我們在 Account.Infrastructure
建立 JwtProvider.cs
檔案,並使他繼承 ITokenProvider
。
using Account.Application;
namespace Account.Infrastructure;
public class JwtProvider : ITokenProvider
{
public string GenerateToken(Guid userId, string firstName, string lastName)
{
throw new NotImplementedException();
}
public string ValidateToken(string token)
{
throw new NotImplementedException();
}
}
GenerateToken
微軟有提供很方便的工具來實作 JWT,直接安裝 Microsoft.IdentityModel.JsonWebTokens
。
using Account.Application;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
namespace Account.Infrastructure;
public class JwtProvider : ITokenProvider
{
public string GenerateToken(Guid userId, string firstName, string lastName)
{
var secret = GenerateHashSecret("my-secret");
Console.WriteLine(secret);
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret));
var signingCredentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.GivenName, firstName),
new Claim(JwtRegisteredClaimNames.FamilyName, lastName),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
var tokenDescriptor = new SecurityTokenDescriptor
{
Issuer = "todo-issuer",
Audience = "todo-audience",
Expires = DateTime.UtcNow.AddMinutes(5),
Subject = new ClaimsIdentity(claims),
SigningCredentials = signingCredentials
};
var tokenHandler = new JsonWebTokenHandler();
return tokenHandler.CreateToken(tokenDescriptor);
}
public string ValidateToken(string token)
{
throw new NotImplementedException();
}
private string GenerateHashSecret(string input)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToBase64String(hash);
}
}
然後註冊一下這個 JwtProvider
public static class AccountInfrastructureRegister
{
public static IServiceCollection AddAccountInfrastructure(this IServiceCollection services)
{
services.AddScoped<IUserRepository, UserRepository>();
services.AddSingleton<ITokenProvider, JwtProvider>();
return services;
}
}
接著再來註冊一個使用者測試看看
我們可以到 JWT.IO 來檢驗一下我們的 Token 是不是合法的
先把 Console 內的 Secret 貼到右下方的 your-256-bit-secret
中,接著就可以把 Response 內的 Token 貼到左方的 Token 中,如果下方顯示 Signature Verified
就代表這個 JWT 是合法用 Secret 做出來的。
通常我們不太會將參數值直接 Hardcode,這裡我們放到 appsettings
內
首先在 Account.Infrastructure
新增 JwtSettings.cs
namespace Account.Infrastructure;
public class JwtSettings
{
public static string SectionName { get; } = "JwtSettings";
public string Secret { get; init; } = null!;
public string Issuer { get; init; } = null!;
public string Audience { get; init; } = null!;
public int ExpiryInMinutes { get; init; }
}
接著在 Account.Infrastructure
安裝 Configuration 工具 Microsoft.Extensions.Configuration
和 Microsoft.Extensions.Configuration.Binder
最後 DI IConfiguration
到 JwtProvider
並實作
using Account.Application;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using Microsoft.Extensions.Configuration;
namespace Account.Infrastructure;
public class JwtProvider : ITokenProvider
{
private readonly IConfiguration _configuration;
private readonly JwtSettings _jwtSettings;
public JwtProvider(IConfiguration configuration)
{
_configuration = configuration;
_jwtSettings = _configuration.GetSection(JwtSettings.SectionName).Get<JwtSettings>() ?? throw new NullReferenceException();
}
public string GenerateToken(Guid userId, string firstName, string lastName)
{
var secret = GenerateHashSecret(_jwtSettings.Secret);
Console.WriteLine(secret);
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret));
var signingCredentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.GivenName, firstName),
new Claim(JwtRegisteredClaimNames.FamilyName, lastName),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
var tokenDescriptor = new SecurityTokenDescriptor
{
Issuer = _jwtSettings.Issuer,
Audience = _jwtSettings.Audience,
Expires = DateTime.UtcNow.AddMinutes(_jwtSettings.ExpiryInMinutes),
Subject = new ClaimsIdentity(claims),
SigningCredentials = signingCredentials
};
var tokenHandler = new JsonWebTokenHandler();
return tokenHandler.CreateToken(tokenDescriptor);
}
public string ValidateToken(string token)
{
throw new NotImplementedException();
}
private string GenerateHashSecret(string input)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToBase64String(hash);
}
}
這樣就可以在 Account.Grpc
的 appsettings.json
內把 JWT 設定的參數放進去
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Kestrel": {
"EndpointDefaults": {
"Protocols": "Http2"
}
},
"JwtSettings": {
"Secret": "my-secret",
"ExpiryInMinutes": 5,
"Issuer": "todo-issuer",
"Audience": "todo-audience"
}
}
ValidateToken
產生了一個 JWT 就要能驗證真偽,所以現在來實作 ValidateToken
的方法。
這邊通常會把所有有關 Identity 的 Claims 都回傳,包含 Role
之類的內容,讓 Gateway 可以依此判定是否能使用此功能,但我這邊偷懶只回個 ID。
public async Task<string?> ValidateTokenAsync(string token)
{
var secret = GenerateHashSecret(_jwtSettings.Secret);
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret));
var validationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = _jwtSettings.Issuer,
ValidateAudience = true,
ValidAudience = _jwtSettings.Audience,
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero,
ValidateIssuerSigningKey = true,
IssuerSigningKey = securityKey,
};
var tokenHandler = new JsonWebTokenHandler();
var result = await tokenHandler.ValidateTokenAsync(token, validationParameters);
return result.ClaimsIdentity?.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
}
因為這裡有非同步的作法,記得 ITokenProvider
也要改
namespace Account.Application;
public interface ITokenProvider
{
string GenerateToken(Guid userId, string firstName, string lastName);
Task<string?> ValidateTokenAsync(string token);
}
把這個方法 Expose 出去,先在 AccountService
新增 ValidateToken
namespace Account.Application;
public interface IAccountService
{
AuthenticationResult Register(string FirstName, string lastName, string email, string password);
AuthenticationResult Login(string email, string password);
Task<Guid> ValidateTokenAsync(string token);
}
public class AccountService : IAccountService
{
// ...
public async Task<Guid> ValidateTokenAsync(string token)
{
var guid = await _tokenProvider.ValidateTokenAsync(token);
Guid.TryParse(guid, out Guid result);
return result;
}
}
增加 Proto Endpoint
syntax = "proto3";
option csharp_namespace = "Account.Grpc";
service AccountGrpcService {
rpc Register (RegisterRequest) returns (AuthenticationResponse);
rpc Login (LoginRequest) returns (AuthenticationResponse);
rpc ValidateToken(ValidateTokenRequest) returns (ValidateTokenResponse);
}
message RegisterRequest {
string FirstName = 1;
string LastName = 2;
string Email = 3;
string Password = 4;
}
message LoginRequest {
string Email = 1;
string Password = 2;
}
message AuthenticationResponse {
string Id = 1;
string FirstName = 2;
string LastName = 3;
string Email = 4;
string Token = 5;
}
message ValidateTokenRequest {
string Token = 1;
}
message ValidateTokenResponse {
bool isValid = 1;
string UserId = 2;
}
Override ValidateToken
public override async Task<ValidateTokenResponse> ValidateToken(ValidateTokenRequest request, ServerCallContext context)
{
var result = await _accountService.ValidateTokenAsync(request.Token);
return new ValidateTokenResponse() { IsValid = result != Guid.Empty, UserId = result.ToString() };
}
測試一下
這篇實在太長了,許多細節直接呈現在程式碼內,希望能讓大家更了解 JWT,未來在實作 BFF Gateway 的時候會再更進一步介紹 Authorization 和 Authentication 的不同。
最後的架構圖